package gitbucket.core.service

import org.slf4j.LoggerFactory
import gitbucket.core.model.{Account, AccountExtraMailAddress, AccountPreference, GroupMember}
import gitbucket.core.model.Profile._
import gitbucket.core.model.Profile.profile.blockingApi._
import gitbucket.core.model.Profile.dateColumnType
import gitbucket.core.util.{LDAPUtil, StringUtil}
import StringUtil._
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jose.crypto.{MACSigner, MACVerifier}
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
import gitbucket.core.plugin.PluginRegistry
import gitbucket.core.service.SystemSettingsService.SystemSettings

import java.security.SecureRandom

trait AccountService {

    private val logger = LoggerFactory.getLogger(classOf[AccountService])

    def authenticate(settings: SystemSettings, userName: String, password: String)(implicit
        s: Session
    ): Option[Account] = {
        val account =
            if (password.isEmpty) { None }
            else if (settings.ldapAuthentication) {
                ldapAuthentication(settings, userName, password)
            } else { defaultAuthentication(userName, password) }

        if (account.isEmpty) { logger.info(s"Failed to authenticate: $userName") }

        account
    }

    /**
   * Authenticate by internal database.
   */
    private def defaultAuthentication(userName: String, password: String)(implicit s: Session) = {
        val pbkdf2re = """^\$pbkdf2-sha256\$(\d+)\$([0-9a-zA-Z+/=]+)\$([0-9a-zA-Z+/=]+)$""".r
        getAccountByUserName(userName).collect {
            case account if !account.isGroupAccount =>
                account.password match {
                    case pbkdf2re(iter, salt, hash)
                        if (pbkdf2_sha256(iter.toInt, salt, password) == hash) => Some(account)
                    case p if p == sha1(password) =>
                        updateAccount(account.copy(password = pbkdf2_sha256(password)))
                        Some(account)
                    case _ => None
                }
            case account if (!account.isGroupAccount && account.password == sha1(password)) =>
                Some(account)
        }.flatten
    }

    /**
   * Authenticate by LDAP.
   */
    private def ldapAuthentication(settings: SystemSettings, userName: String, password: String)(
        implicit s: Session
    ): Option[Account] = {
        LDAPUtil.authenticate(settings.ldap.get, userName, password) match {
            case Right(ldapUserInfo) => {
                // Create or update account by LDAP information
                getAccountByUserName(ldapUserInfo.userName, true) match {
                    case Some(x) =>
                        if (x.isRemoved) {
                            logger.info(
                              "LDAP Authentication Failed: Account is already registered but disabled."
                            )
                            defaultAuthentication(userName, password)
                        } else {
                            if (settings.ldap.get.mailAttribute.getOrElse("").isEmpty) {
                                updateAccount(x.copy(fullName = ldapUserInfo.fullName))
                            } else {
                                updateAccount(x.copy(
                                  mailAddress = ldapUserInfo.mailAddress,
                                  fullName = ldapUserInfo.fullName
                                ))
                            }
                            getAccountByUserName(ldapUserInfo.userName)
                        }
                    case None => getAccountByMailAddress(ldapUserInfo.mailAddress, true) match {
                            case Some(x) =>
                                if (x.isRemoved) {
                                    logger.info(
                                      "LDAP Authentication Failed: Account is already registered but disabled."
                                    )
                                    defaultAuthentication(userName, password)
                                } else {
                                    updateAccount(x.copy(fullName = ldapUserInfo.fullName))
                                    getAccountByUserName(ldapUserInfo.userName)
                                }
                            case None => {
                                createAccount(
                                  ldapUserInfo.userName,
                                  "",
                                  ldapUserInfo.fullName,
                                  ldapUserInfo.mailAddress,
                                  false,
                                  None,
                                  None
                                )
                                getAccountByUserName(ldapUserInfo.userName)
                            }
                        }
                }
            }
            case Left(errorMessage) => {
                logger.info(s"LDAP error: ${errorMessage}")
                defaultAuthentication(userName, password)
            }
        }
    }

    def getAccountByUserName(userName: String, includeRemoved: Boolean = false)(implicit
        s: Session
    ): Option[Account] = Accounts filter
        (t =>
            (t.userName === userName.bind).&&(t.removed === false.bind, !includeRemoved)
        ) firstOption

    def getAccountByUserNameIgnoreCase(userName: String, includeRemoved: Boolean = false)(implicit
        s: Session
    ): Option[Account] = Accounts filter
        (t =>
            (t.userName.toLowerCase === userName.toLowerCase.bind)
                .&&(t.removed === false.bind, !includeRemoved)
        ) firstOption

    def getAccountsByUserNames(
        userNames: Set[String],
        knowns: Set[Account],
        includeRemoved: Boolean = false
    )(implicit s: Session): Map[String, Account] = {
        val map = knowns.map(a => a.userName -> a).toMap
        val needs = userNames -- map.keySet
        if (needs.isEmpty) { map }
        else {
            map ++ Accounts.filter(t =>
                (t.userName inSetBind needs).&&(t.removed === false.bind, !includeRemoved)
            ).list.map(a => a.userName -> a).toMap
        }
    }

    def getAccountByMailAddress(mailAddress: String, includeRemoved: Boolean = false)(implicit
        s: Session
    ): Option[Account] =
        (Accounts joinLeft AccountExtraMailAddresses on { case (a, e) =>
            a.userName === e.userName
        }).filter { case (a, x) =>
            ((a.mailAddress.toLowerCase === mailAddress.toLowerCase.bind) ||
                (x.map { e => e.extraMailAddress.toLowerCase === mailAddress.toLowerCase.bind }
                    .getOrElse(false.bind))).&&(a.removed === false.bind, !includeRemoved)
        }.map { case (a, e) => a } firstOption

    def getAllUsers(includeRemoved: Boolean = true, includeGroups: Boolean = true)(implicit
        s: Session
    ): List[Account] = {
        Accounts filter { t =>
            (1.bind === 1.bind).&&(t.groupAccount === false.bind, !includeGroups)
                .&&(t.removed === false.bind, !includeRemoved)
        } sortBy (_.userName) list
    }

    def isLastAdministrator(account: Account)(implicit s: Session): Boolean = {
        if (account.isAdmin) {
            (Accounts filter (_.removed === false.bind) filter (_.isAdmin === true.bind) map
                (_.userName.length)).first == 1
        } else false
    }

    def createAccount(
        userName: String,
        password: String,
        fullName: String,
        mailAddress: String,
        isAdmin: Boolean,
        description: Option[String],
        url: Option[String]
    )(implicit s: Session): Account = {
        val account = Account(
          userName = userName,
          password = password,
          fullName = fullName,
          mailAddress = mailAddress,
          isAdmin = isAdmin,
          url = url,
          registeredDate = currentDate,
          updatedDate = currentDate,
          lastLoginDate = None,
          image = None,
          isGroupAccount = false,
          isRemoved = false,
          description = description
        )
        Accounts insert account
        account
    }

    def suspendAccount(account: Account)(implicit s: Session): Unit = {
        // Remove from GROUP_MEMBER and COLLABORATOR
        removeUserRelatedData(account.userName)
        updateAccount(account.copy(isRemoved = true))

        // call hooks
        PluginRegistry().getAccountHooks.foreach(_.deleted(account.userName))
    }

    def updateAccount(account: Account)(implicit s: Session): Unit = Accounts.filter { a =>
        a.userName === account.userName.bind
    }.map { a =>
        (
          a.password,
          a.fullName,
          a.mailAddress,
          a.isAdmin,
          a.url.?,
          a.registeredDate,
          a.updatedDate,
          a.lastLoginDate.?,
          a.removed,
          a.description.?
        )
    }.update(
      account.password,
      account.fullName,
      account.mailAddress,
      account.isAdmin,
      account.url,
      account.registeredDate,
      currentDate,
      account.lastLoginDate,
      account.isRemoved,
      account.description
    )

    def updateAvatarImage(userName: String, image: Option[String])(implicit s: Session): Unit =
        Accounts.filter(_.userName === userName.bind).map(_.image.?).update(image)

    def getAccountExtraMailAddresses(userName: String)(implicit s: Session): List[String] = {
        AccountExtraMailAddresses.filter(_.userName === userName.bind).map(_.extraMailAddress) list
    }

    def updateAccountExtraMailAddresses(userName: String, mails: List[String])(implicit
        s: Session
    ): Unit = {
        AccountExtraMailAddresses.filter(_.userName === userName.bind).delete
        mails.map(AccountExtraMailAddresses insert AccountExtraMailAddress(userName, _))
    }

    def updateLastLoginDate(userName: String)(implicit s: Session): Unit = Accounts
        .filter(_.userName === userName.bind).map(_.lastLoginDate).update(currentDate)

    def createGroup(groupName: String, description: Option[String], url: Option[String])(implicit
        s: Session
    ): Account = {
        val group = Account(
          userName = groupName,
          password = "",
          fullName = groupName,
          mailAddress = groupName + "@devnull",
          isAdmin = false,
          url = url,
          registeredDate = currentDate,
          updatedDate = currentDate,
          lastLoginDate = None,
          image = None,
          isGroupAccount = true,
          isRemoved = false,
          description = description
        )
        Accounts insert group
        group
    }

    def updateGroup(
        groupName: String,
        description: Option[String],
        url: Option[String],
        removed: Boolean
    )(implicit s: Session): Unit = Accounts.filter(_.userName === groupName.bind)
        .map(t => (t.url.?, t.description.?, t.updatedDate, t.removed))
        .update(url, description, currentDate, removed)

    def updateGroupMembers(groupName: String, members: List[(String, Boolean)])(implicit
        s: Session
    ): Unit = {
        GroupMembers.filter(_.groupName === groupName.bind).delete
        members.foreach { case (userName, isManager) =>
            GroupMembers insert GroupMember(groupName, userName, isManager)
        }
    }

    def getGroupMembers(groupName: String)(implicit s: Session): List[GroupMember] = GroupMembers
        .filter(_.groupName === groupName.bind).sortBy(_.userName).list

    def getGroupsByUserName(userName: String)(implicit s: Session): List[String] = GroupMembers
        .filter(_.userName === userName.bind).sortBy(_.groupName).map(_.groupName).list

    def removeUserRelatedData(userName: String)(implicit s: Session): Unit = {
        GroupMembers.filter(_.userName === userName.bind).delete
        Collaborators.filter(_.collaboratorName === userName.bind).delete
    }

    def removeUser(account: Account)(implicit s: Session): Unit = {
        // Remove from GROUP_MEMBER and COLLABORATOR
        removeUserRelatedData(account.userName)
        updateAccount(account.copy(isRemoved = true))

        // call hooks
        PluginRegistry().getAccountHooks.foreach(_.deleted(account.userName))
    }

    def getGroupNames(userName: String)(implicit s: Session): List[String] = {
        List(userName) ++ Collaborators.filter(_.collaboratorName === userName.bind)
            .sortBy(_.userName).map(_.userName).list.distinct
    }

    /*
     * For account preference
     */
    def getAccountPreference(userName: String)(implicit s: Session): Option[AccountPreference] = {
        AccountPreferences filter (_.byPrimaryKey(userName)) firstOption
    }

    def addAccountPreference(userName: String, highlighterTheme: String)(implicit
        s: Session
    ): Unit = {
        AccountPreferences insert
            AccountPreference(userName = userName, highlighterTheme = highlighterTheme)
    }

    def updateAccountPreference(userName: String, highlighterTheme: String)(implicit
        s: Session
    ): Unit = {
        AccountPreferences.filter(_.byPrimaryKey(userName)).map(t => t.highlighterTheme)
            .update(highlighterTheme)
    }

    def addOrUpdateAccountPreference(userName: String, highlighterTheme: String)(implicit
        s: Session
    ): Unit = {
        getAccountPreference(userName) match {
            case Some(_) => updateAccountPreference(userName, highlighterTheme)
            case _       => addAccountPreference(userName, highlighterTheme)
        }
    }

    def generateResetPasswordToken(mailAddress: String): String = {
        val claimsSet = new JWTClaimsSet.Builder().claim("mailAddress", mailAddress)
            .expirationTime(new java.util.Date(System.currentTimeMillis() + 10 * 1000)).build()

        val signedJWT = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), claimsSet)
        signedJWT.sign(new MACSigner(AccountService.jwtSecretKey))

        signedJWT.serialize()
    }

    def decodeResetPasswordToken(token: String): Option[String] = {
        try {
            val signedJWT = SignedJWT.parse(token)
            val verifier = new MACVerifier(AccountService.jwtSecretKey)
            if (
              signedJWT.verify(verifier) &&
              new java.util.Date().before(signedJWT.getJWTClaimsSet().getExpirationTime())
            ) { Some(signedJWT.getPayload.toJSONObject.get("mailAddress").toString) }
            else None
        } catch { case _: Exception => None }
    }
}

object AccountService extends AccountService {
    // 256-bit key for HS256 which must be pre-shared
    val jwtSecretKey = new Array[Byte](32)
    new SecureRandom().nextBytes(jwtSecretKey)
}
